This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / routes / [handle] / +page.ts
10 kB 325 lines
1import type { Did } from "@atcute/lexicons/syntax"; 2import { createBobbinClient } from "$lib/api/client"; 3import { fetchPage, items } from "$lib/api/pagination"; 4import { count } from "$lib/api/count"; 5import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; 6import { IdentityCache } from "$lib/api/identity"; 7import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8import { toHttpError, parallel } from "$lib/api/load"; 9import { search } from "$lib/api/search"; 10import type { BobbinContext } from "$lib/api/client"; 11import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; 12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15import type { 16 RepoCardData, 17 StringCardData, 18 PersonData, 19 VouchData, 20 StarData 21} from "$lib/components/profile/types"; 22import type { PageLoad } from "./$types"; 23 24const PAGE_LIMIT = 50; 25 26const TABS = [ 27 "overview", 28 "repos", 29 "starred", 30 "strings", 31 "followers", 32 "following", 33 "vouches" 34] as const; 35type Tab = (typeof TABS)[number]; 36 37const normalizeTab = (raw: string | null): Tab => 38 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 39 40interface ListItem { 41 uri: string; 42 value: unknown; 43} 44 45const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 46 const value = item.value as RepoRecord; 47 return { 48 rkey: rkeyFromUri(item.uri), 49 name: value.name ?? rkeyFromUri(item.uri), 50 repoDid: value.repoDid ?? "", 51 ownerHandle, 52 description: value.description, 53 knot: value.knot, 54 createdAt: value.createdAt 55 }; 56}; 57 58interface ResolveRepoCardOptions { 59 viewerStarRkeys?: ReadonlyMap<string, string>; 60} 61 62const resolveRepoCard = async ( 63 ctx: BobbinContext, 64 item: ListItem, 65 ownerHandle: string, 66 options: ResolveRepoCardOptions = {} 67): Promise<RepoCardData> => { 68 const repo = toRepoCard(item, ownerHandle); 69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 // TODO(bobbin): instead of doing this, listing repos should return star counts 71 // and most likely other stats as well. 72 const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); 73 return { 74 ...repo, 75 stars: stars.count, 76 viewerStarRkey: options.viewerStarRkeys 77 ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) 78 : undefined 79 }; 80}; 81 82const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 83 const value = item.value as ShTangledString.Main; 84 return { 85 rkey: rkeyFromUri(item.uri), 86 ownerHandle, 87 filename: value.filename, 88 description: value.description, 89 createdAt: value.createdAt, 90 lines: value.contents?.split("\n").length ?? 1 91 }; 92}; 93 94// resolve dids -> handle/avatar, deduped, preserving input order. 95const resolvePeople = async ( 96 ctx: BobbinContext, 97 dids: string[], 98 viewerDid?: string 99): Promise<PersonData[]> => { 100 const cache = new IdentityCache(ctx); 101 const unique = [...new Set(dids)]; 102 103 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 104 // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. 105 const counts = await parallel( 106 unique.reduce( 107 (acc, did) => { 108 acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) 109 .then((result) => result.count) 110 .catch(() => 0); 111 acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) 112 .then((result) => result.count) 113 .catch(() => 0); 114 return acc; 115 }, 116 {} as Record<string, Promise<number>> 117 ) 118 ); 119 120 const viewerFollowRkeys = new Map<string, string>(); 121 if (viewerDid) { 122 for await (const item of items( 123 ctx, 124 "sh.tangled.graph.listFollowsBy", 125 { subject: viewerDid as Did }, 126 { maxPages: 10 } 127 )) { 128 const value = item.value as FollowRecord; 129 viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); 130 } 131 } 132 133 const byDid = new Map<string, PersonData>(); 134 unique.forEach((did, index) => { 135 const doc = docs[index]; 136 const followers = counts[`${did}-followers`]; 137 const following = counts[`${did}-following`]; 138 const isSelf = viewerDid === did; 139 const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; 140 byDid.set( 141 did, 142 doc 143 ? { 144 did: doc.did, 145 handle: doc.handle, 146 avatar: doc.avatar, 147 followers, 148 following, 149 isSelf, 150 viewerFollowRkey 151 } 152 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 153 ); 154 }); 155 return unique.map((did) => byDid.get(did) as PersonData); 156}; 157 158const resolveVouches = async (ctx: BobbinContext, items: ListItem[]): Promise<VouchData[]> => { 159 const cache = new IdentityCache(ctx); 160 return Promise.all( 161 items.map(async (item): Promise<VouchData> => { 162 const value = item.value as VouchRecord; 163 const voucher = didFromUri(item.uri); 164 const doc = await cache.resolve(voucher).catch(() => null); 165 return { 166 uri: item.uri, 167 did: voucher, 168 handle: doc?.handle ?? voucher, 169 avatar: doc?.avatar, 170 kind: value.kind === "denounce" ? "denounce" : "vouch", 171 reason: value.reason, 172 createdAt: value.createdAt 173 }; 174 }) 175 ); 176}; 177 178const resolveStars = async ( 179 ctx: BobbinContext, 180 items: ListItem[], 181 options: ResolveRepoCardOptions 182): Promise<StarData[]> => { 183 const cache = new IdentityCache(ctx); 184 const resolved = await Promise.all( 185 items.map(async (item): Promise<StarData | null> => { 186 const value = item.value as ShTangledFeedStar.Main; 187 const subject = value.subject; 188 if (subject && "did" in subject && subject.did) { 189 try { 190 const repo = await getRepoByRepoDid(ctx, subject.did); 191 const ownerDid = didFromUri(repo.uri); 192 const owner = await cache.resolve(ownerDid).catch(() => null); 193 return { 194 kind: "repo", 195 uri: item.uri, 196 createdAt: value.createdAt, 197 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) 198 }; 199 } catch { 200 return null; 201 } 202 } 203 if (subject && "uri" in subject && subject.uri) { 204 const ownerDid = didFromUri(subject.uri); 205 const owner = await cache.resolve(ownerDid).catch(() => null); 206 return { 207 kind: "string", 208 uri: item.uri, 209 createdAt: value.createdAt, 210 ownerHandle: owner?.handle ?? ownerDid, 211 rkey: rkeyFromUri(subject.uri) 212 }; 213 } 214 return null; 215 }) 216 ); 217 return resolved.filter((star): star is StarData => star !== null); 218}; 219 220export const load: PageLoad = async (event) => { 221 const parent = await event.parent(); 222 const tab = normalizeTab(event.url.searchParams.get("tab")); 223 224 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 225 226 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 227 const did = parent.identity.did as Did; 228 const handle = parent.identity.handle; 229 230 try { 231 switch (tab) { 232 case "repos": { 233 const q = event.url.searchParams.get("q")?.trim(); 234 const [found, viewerStarRkeys] = await Promise.all([ 235 q 236 ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( 237 (page) => page.hits 238 ) 239 : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( 240 (page) => page.items 241 ), 242 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 243 ]); 244 return { 245 tab, 246 repos: await Promise.all( 247 found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 248 ) 249 }; 250 } 251 case "strings": { 252 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 253 subject: did, 254 limit: PAGE_LIMIT 255 }); 256 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 257 } 258 case "followers": { 259 const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { 260 subject: did, 261 limit: PAGE_LIMIT 262 }); 263 const dids = page.items.map((item) => didFromUri(item.uri)); 264 return { 265 tab, 266 people: await resolvePeople(ctx, dids, parent.auth?.did) 267 }; 268 } 269 case "following": { 270 const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { 271 subject: did, 272 limit: PAGE_LIMIT 273 }); 274 const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 275 return { 276 tab, 277 people: await resolvePeople(ctx, dids, parent.auth?.did) 278 }; 279 } 280 case "vouches": { 281 const page = await fetchPage(ctx, "sh.tangled.graph.listVouches", { 282 subject: did, 283 limit: PAGE_LIMIT 284 }); 285 return { tab, vouches: await resolveVouches(ctx, page.items) }; 286 } 287 case "starred": { 288 const [page, viewerStarRkeys] = await Promise.all([ 289 fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), 290 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 291 ]); 292 return { 293 tab, 294 stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 295 }; 296 } 297 case "overview": 298 default: { 299 const [page, viewerStarRkeys] = await Promise.all([ 300 fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), 301 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 302 ]); 303 304 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 305 const byKey = new Map<string, ListItem>(); 306 for (const item of page.items) { 307 const value = item.value as RepoRecord; 308 if (value.repoDid) byKey.set(value.repoDid, item); 309 byKey.set(item.uri, item); 310 } 311 const pinnedItems = pinnedKeys 312 .map((key) => byKey.get(key)) 313 .filter((item): item is ListItem => item !== undefined); 314 const pinned = await Promise.all( 315 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 316 ); 317 318 return { tab: "overview" as const, overview: { pinned } }; 319 } 320 } 321 } catch (cause) { 322 console.error("Page load error:", cause); 323 toHttpError(cause, "Could not load profile data"); 324 } 325};